/**
 * Copyright 2010 Marcus Calverley <marcus@calverley.dk>
 * 
 * This file is part of I'm Busy.
 *
 * I'm Busy is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * I'm Busy is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with I'm Busy.  If not, see <http://www.gnu.org/licenses/>.
 */
package dk.lmz.android.busy;

import java.util.ArrayList;
import java.util.HashSet;

import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Vibrator;
import android.preference.PreferenceManager;
import android.util.Log;
import android.widget.Toast;

/**
 * This class is the main service of the program.
 * It monitors the calendar for changes and sets alarms to enable and disable busy mode.
 *
 * @author Marcus Calverley <marcus@calverley.dk>
 */
public class BusyService extends Service
{
	// URI for calendar changed in FroYo
	private static final String calendarProvider = String.format("content://%s/", (Integer.parseInt(Build.VERSION.SDK) >= 8) ? "com.android.calendar" : "calendar");
	private static boolean shutdown = false;

	// Content providers used
	private static final Uri events = Uri.parse(calendarProvider + "events");
	private static final Uri instances = Uri.parse(calendarProvider + "instances/when");
	public static final Uri calendars = Uri.parse(calendarProvider + "calendars");

	// Only look ahead in the calendar a limited time
	private static final long lookAheadMillis = 12 * 60 * 60 * 1000;

	// Holds the observers registered for unregistering when stopping the service
	private CalendarEventObserver eventObserver;
	private CalendarListObserver calendarObserver;
	private PreferenceChangeListener preferenceListener;

	// Holds the intent used to set the alarm
	private PendingIntent busyModeCheck;

	@Override
	public void onStart(Intent intent, int startId)
	{
		try
		{
			setBusyMode();
		}
		catch (CalendarsNotAvailableException e)
		{
			setCalendarNotAvailableNotification(this);
		}
	}

	/**
	 * Starts service by setting correct busy mode and registering the content observers.
	 */
	@Override
	public void onCreate()
	{
		super.onCreate();

		ContentResolver cr = getContentResolver();
		// Register content observer to react to changes to the list of calendars
		calendarObserver = new CalendarListObserver(new Handler(), this);
		cr.registerContentObserver(calendars, true, calendarObserver);

		// Register content observer to react to changes to the list of calendars
		eventObserver = new CalendarEventObserver(new Handler(), this);
		cr.registerContentObserver(events, true, eventObserver);

		// Listen to preference changes so that a message can be displayed about certain weird configurations
		preferenceListener = new PreferenceChangeListener();
		PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(preferenceListener);

		// Check service configuration, but don't set busy mode, as this is done in onStart()
		try
		{
			checkConfiguration(this);
		}
		catch (CalendarsNotAvailableException e)
		{
			log("Calendars not available");
		}

		// Set up pending intent for alarms
		busyModeCheck = PendingIntent.getBroadcast(this, 0, new Intent(this, BusyModeCheck.class), PendingIntent.FLAG_CANCEL_CURRENT);

		log("Service started successfully");
	}

	private static void stop(Context context)
	{
		// Stop service normally
		shutdown = true;
		context.stopService(new Intent(context, BusyService.class));
	}

	/**
	 * Unregisters content observer, cancels alarm to set busy mode and sets phone mode back.
	 */
	@Override
	public void onDestroy()
	{
		super.onDestroy();

		// Stop listening for preference changes
		PreferenceManager.getDefaultSharedPreferences(this).unregisterOnSharedPreferenceChangeListener(preferenceListener);

		// Unregister content observers
		ContentResolver cr = getContentResolver();
		cr.unregisterContentObserver(calendarObserver);
		cr.unregisterContentObserver(eventObserver);

		// Cancel current alarm
		AlarmManager alarm = (AlarmManager) getSystemService(ALARM_SERVICE);
		alarm.cancel(busyModeCheck);

		// Disable vibrate mode if set by this application
		setPhoneMode(false, false);

		// If not shutting down normally, we set a notification
		if (!shutdown)
		{
			// Send a notification that the service has stopped
			Notification notification = new Notification(R.drawable.icon, getString(R.string.disabled), 0);
			PendingIntent pi = PendingIntent.getActivity(this, 0, new Intent(this, SettingsActivity.class), 0);
			notification.setLatestEventInfo(this, getString(R.string.app_name), getString(R.string.disabled_explain), pi);
			NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
			nm.notify(0, notification);
		}
		else
		{
			shutdown = false;
		}
	}

	/**
	 * Make sure phone is in correct mode
	 * @throws CalendarsNotAvailableException
	 */
	public synchronized void setBusyMode() throws CalendarsNotAvailableException
	{
		long beforeMillis = getBeforeMillis(), afterMillis = getAfterMillis();
		long nextAlarmTime;

		try // see if phone is in busy mode
		{
			nextAlarmTime = getBusyOffAlarmTime(beforeMillis, afterMillis);

			// Set vibrate mode
			setPhoneMode(true, true);
			log("Currently in busy mode");
		}
		catch (NoEventsException _) // No events keeping phone in busy mode
		{
			// Put phone back to normal
			setPhoneMode(false, true);

			try
			{
				nextAlarmTime = getBusyOnAlarmTime(beforeMillis, afterMillis);
				BusyService.log("Not in busy mode, busy mode at next check");
			}
			catch (NoEventsException e) // No future events found
			{
				nextAlarmTime = e.nextAlarmTime;
				BusyService.log("Not in busy mode, no events found");
			}
		}
		BusyService.log("Next check for busy mode: " + nextAlarmTime);
		AlarmManager alarm = (AlarmManager) getSystemService(ALARM_SERVICE);
		alarm.set(AlarmManager.RTC_WAKEUP, nextAlarmTime, busyModeCheck);
	}

	/**
	 * Attempt to get the start of busy mode for the next event.
	 * An exception is raised when no events can be found.
	 * @param busyBefore
	 * @param busyAfter
	 * @return
	 * @throws NoEventsException
	 * @throws CalendarsNotAvailableException
	 */
	private final long getBusyOnAlarmTime(long busyBefore, long busyAfter) throws NoEventsException, CalendarsNotAvailableException
	{
		// Get next event that needs busy mode
		long now = System.currentTimeMillis();
		final String[] projection = new String[] { "begin" };
		String selection = "transparency=0 AND (eventStatus <> 2 OR eventStatus IS NULL) AND calendar_id "
			+ (getPrefExcludeSelectedCalendars(this) ? "NOT IN" : "IN")
			+ " (" + getPrefSelectedCalendars(this) + ")"
			+ (getPrefIgnoreAllDayEvents(this) ? " AND allDay=0" : "");
		final String sortOrder = "begin ASC";

		Cursor cursor = null;
		try
		{
			cursor = getContentResolver().query(buildUri(now - busyAfter, now + busyBefore + lookAheadMillis), projection, selection, null, sortOrder);
		}
		catch (SQLiteException _)
		{
			throw new CalendarsNotAvailableException();
		}

		// Database cursor should never be null, but it happens if Google Calendar crashes
		if (cursor == null)
		{
			throw new CalendarsNotAvailableException();
		}
		else if (!cursor.moveToFirst())
		{
			// If there are no future events in the calendar, the alarm should be set to try again later
			throw new NoEventsException("No future events in the calendar found", now + lookAheadMillis);
		}
		return cursor.getLong(0) - busyBefore;
	}

	/**
	 * Get the end of the busy mode of the current event.
	 * NoEventsException means that no events are keeping phone in busy mode.
	 * @param busyBefore
	 * @param busyAfter
	 * @return
	 * @throws NoEventsException
	 * @throws CalendarsNotAvailableException
	 */
	private long getBusyOffAlarmTime(long busyBefore, long busyAfter) throws NoEventsException, CalendarsNotAvailableException
	{
		final long now = System.currentTimeMillis();

		final String[] projection = new String[] { "end" };
		String selection = "transparency=0 AND (eventStatus <> 2 OR eventStatus IS NULL) AND calendar_id "
			+ (getPrefExcludeSelectedCalendars(this) ? "NOT IN" : "IN")
			+ " (" + getPrefSelectedCalendars(this) + ")"
			+ (getPrefIgnoreAllDayEvents(this) ? " AND allDay=0" : "");
		final String sortOrder = "end DESC";

		Cursor cursor = null;
		try
		{
			cursor = getContentResolver().query(buildUri(now - busyAfter, now + busyBefore), projection, selection, null, sortOrder);
		}
		catch (SQLiteException _)
		{
			throw new CalendarsNotAvailableException();
		}

		// Database cursor should never be null, but it happens if Google Calendar crashes
		if (cursor == null)
		{
			throw new CalendarsNotAvailableException();
		}
		else if (!cursor.moveToFirst())
		{
			throw new NoEventsException("No events keeping phone in busy mode", 0);
		}
		return cursor.getLong(0) + busyAfter;
	}

	/**
	 * Set a notification to inform the user that the calendar provider is not available.
	 */
	private static final void setCalendarNotAvailableNotification(Context context)
	{
		NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
		Notification notification = new Notification(R.drawable.icon, context.getString(R.string.service_error), 0);
		PendingIntent contentIntent = PendingIntent.getActivity(context, 0, new Intent(context, SettingsActivity.class), 0);
		notification.setLatestEventInfo(context, context.getString(R.string.app_name), context.getString(R.string.calendars_not_available), contentIntent);
		nm.notify(0, notification);
	}

	/**
	 * Enables or disables vibrate if phone is in correct mode.
	 * @param context
	 * @param vibrate Turn on vibrate if true, else turn off.
	 * @param resId Message to display if changing vibrate settings.
	 */
	private final void setPhoneMode(Boolean vibrate, boolean showMessage)
	{
		AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE);
		SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);

		if (vibrate)
		{
			// Set vibrate mode if phone is currently in normal mode (i.e. do not override silent mode)
			if (am.getRingerMode() == AudioManager.RINGER_MODE_NORMAL)
			{
				BusyService.log("Enabling vibrate mode");
				am.setRingerMode(AudioManager.RINGER_MODE_VIBRATE);
				if (showMessage)
				{
					Toast.makeText(this, R.string.you_are_busy, Toast.LENGTH_LONG).show();
				}
				hapticFeedback();

				// Store that this application turned on vibrate
				Editor e = prefs.edit();
				e.putBoolean("vibrateEnabled", true);
				e.commit();
			}
		}
		else
		{
			// If this application turned on vibrate and the phone is still on vibrate, disable it
			if (prefs.getBoolean("vibrateEnabled", false) && am.getRingerMode() == AudioManager.RINGER_MODE_VIBRATE)
			{
				BusyService.log("Disabling vibrate mode");
				am.setRingerMode(AudioManager.RINGER_MODE_NORMAL);
				if (showMessage)
				{
					Toast.makeText(this, R.string.event_ended, Toast.LENGTH_LONG).show();
				}
				hapticFeedback();

				// Store that this application turned off vibrate
				Editor e = prefs.edit();
				e.putBoolean("vibrateEnabled", false);
				e.commit();
			}
			// TODO if event ends and vibrator was enabled, but phone is not on vibrate, vibrateEnabled should be reset to false...
		}
	}

	/**
	 * Provide some haptic feedback for certain actions.
	 * @param context
	 */
	private final void hapticFeedback()
	{
		SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
		if (prefs.getBoolean("vibrate", true))
		{
			Vibrator v = (Vibrator) getSystemService(VIBRATOR_SERVICE);
			v.vibrate(100);
		}
	}

	/**
	 * Add a debug message to the system log.
	 * @param msg
	 */
	public static final void log(String msg)
	{
		Log.d("dk.lmz.android.busy", msg);
	}

	/**
	 * Get the user configured number of milliseconds that the phone should be in busy mode before an event starts.
	 * @param context
	 * @return
	 */
	private final long getBeforeMillis()
	{
		return getPrefMillis("beforeMinutes");
	}

	/**
	 * Get the user configured number of milliseconds that the phone should be in busy mode after an event ends.
	 * @param context
	 * @return
	 */
	private final long getAfterMillis()
	{
		return getPrefMillis("afterMinutes");
	}

	/**
	 * Get a number of milliseconds from user preferences.
	 * @param context
	 * @param preference
	 * @return
	 */
	private final long getPrefMillis(String preference)
	{
		SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
		return Integer.decode(prefs.getString(preference, "0")) * 60 * 1000;
	}

	public static final ArrayList<Integer> getAvailableCalendars(Context context) throws CalendarsNotAvailableException
	{
		ArrayList<Integer> availableCalendars = new ArrayList<Integer>();

		final String[] projection = new String[] { "_id" };

		Cursor cursor = context.getContentResolver().query(BusyService.calendars, projection, null, null, null);

		if (cursor == null)
		{
			throw new CalendarsNotAvailableException();
		}

		while (cursor.moveToNext())
		{
			availableCalendars.add(cursor.getInt(0));
		}
		cursor.close();
		return availableCalendars;
	}

	/**
	 * Set selected calendars to ones that actually exist.
	 * This method is synchronised to try to stop a race condition where saves overwrite each other.
	 *
	 * @param context
	 * @param selectedCalendars
	 */
	public synchronized static final void setSelectedCalendars(Context context, HashSet<Integer> selectedCalendars)
	{
		try
		{
			// Get calendars available
			ArrayList<Integer> availableCalendars = getAvailableCalendars(context);

			// Remove all calendars that are no longer available
			if (selectedCalendars.retainAll(availableCalendars))
			{
				// The selected calendars have been modified, so save the changes
				Editor e = PreferenceManager.getDefaultSharedPreferences(context).edit();
				e.putString("selectedCalendars", buildSelectedCalendarPref(selectedCalendars));
				e.commit();
			}
		}
		catch (CalendarsNotAvailableException e)
		{
			setCalendarNotAvailableNotification(context);
		}
	}

	public static final HashSet<Integer> getSelectedCalendars(Context context)
	{
		String[] calendars = getPrefSelectedCalendars(context).split(",");
		HashSet<Integer> result = new HashSet<Integer>(calendars.length);

		for (String calendar : calendars)
		{
			try
			{
				result.add(Integer.parseInt(calendar));
			}
			catch (NumberFormatException e)
			{
				// Skip invalid IDs
			}
		}
		return result;
	}

	public static final String buildSelectedCalendarPref(HashSet<Integer> selections)
	{
		StringBuilder result = new StringBuilder();
		for (int calendar : selections)
		{
			result.append(calendar);
			result.append(',');
		}
		return (result.length() > 0) ? result.substring(0, result.length() - 1) : "";
	}

	public static final boolean getPrefActive(Context context)
	{
		SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
		return prefs.getBoolean("active", false);
	}

	private static final String getPrefSelectedCalendars(Context context)
	{
		SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
		return prefs.getString("selectedCalendars", "");
	}

	private static final boolean getPrefExcludeSelectedCalendars(Context context)
	{
		SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
		return prefs.getBoolean("excludeSelectedCalendars", false);
	}

	private static final boolean getPrefIgnoreAllDayEvents(Context context)
	{
		SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
		return prefs.getBoolean("ignoreAllDayEvents", true);
	}

	/**
	 * Checks that the user's preferences make sense.
	 * @throws CalendarsNotAvailableException
	 */
	public static void checkConfiguration(Context context) throws CalendarsNotAvailableException
	{
		if (getPrefExcludeSelectedCalendars(context))
		{
			HashSet<Integer> availableCalendars = new HashSet<Integer>(getAvailableCalendars(context));

			// Notify user if all calendars have been excluded
			if (getPrefSelectedCalendars(context).equals(buildSelectedCalendarPref(availableCalendars)))
			{
				Toast.makeText(context, R.string.all_calendars_excluded, Toast.LENGTH_LONG).show();
			}
		}
		else
		{
			// Notify user if no calendars have been selected
			if ("".equals(getPrefSelectedCalendars(context)))
			{
				Toast.makeText(context, R.string.no_calendars_selected, Toast.LENGTH_LONG).show();
			}
		}
	}

	/**
	 * Used to monitor changes to the user's preferences and respond to them immediately.
	 * 
	 * @author Marcus Calverley <marcus@calverley.dk>
	 */
	private class PreferenceChangeListener implements OnSharedPreferenceChangeListener
	{
		public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key)
		{
			// Check service if one of the preferences that might require rescheduling is changed
			if (key.equals("selectedCalendars")
					|| key.equals("excludeSelectedCalendars")
					|| key.equals("beforeMinutes")
					|| key.equals("afterMinutes")
					|| key.equals("ignoreAllDayEvents"))
			{
				try
				{
					checkConfiguration(getApplicationContext());
					log("Preferences changed, rescheduling");
					setBusyMode();
				}
				catch (CalendarsNotAvailableException e)
				{
					setCalendarNotAvailableNotification(getApplicationContext());
				}
			}
			else if (key.equals("active"))
			{
				if (!sharedPreferences.getBoolean("active", false))
				{
					BusyService.stop(getApplicationContext());
				}
			}
		}
	}

	/**
	 * Build an URI for getting instances of events in the calendar.
	 * @param begin
	 * @param end
	 * @return
	 */
	private static final Uri buildUri(long begin, long end)
	{
		Uri.Builder builder = instances.buildUpon();
		ContentUris.appendId(builder, begin);
		ContentUris.appendId(builder, end);
		return builder.build();
	}

	@Override
	public IBinder onBind(Intent intent)
	{
		// TODO Auto-generated method stub
		return null;
	}
}
